package com.mercadoeletronico.annotationProcessor;

import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.util.UriTemplate;

import javax.annotation.processing.AbstractProcessor;
import javax.annotation.processing.RoundEnvironment;
import javax.annotation.processing.SupportedAnnotationTypes;
import javax.annotation.processing.SupportedSourceVersion;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.*;
import javax.lang.model.util.ElementFilter;
import javax.tools.Diagnostic;
import javax.tools.JavaFileObject;
import java.io.Writer;
import java.util.*;

@SupportedAnnotationTypes("org.springframework.web.bind.annotation.RequestMapping")
@SupportedSourceVersion(SourceVersion.RELEASE_7)
public class MVCControllerURLAnnotationProcessor extends AbstractProcessor {
// ------------------------------ FIELDS ------------------------------

    private Map<Element, ArrayList<ReqMapMethod>> allMaps;
    private Integer runNumber = 0;

// ------------------------ INTERFACE METHODS ------------------------


// --------------------- Interface Processor ---------------------

    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        long startMillis = System.currentTimeMillis();

        // Avoid running more than once; we do not need to process our own generations, or other generator's generation
        if (roundEnv.processingOver()) return false;
        if (runNumber > 0) return false;
        runNumber++;

        try {
            //log("Round com.mercadoeletronico.annotationProcessor.MVCControllerURLAnnotationProcessor: " + roundEnv.toString());
            processingEnv.getMessager().printMessage(Diagnostic.Kind.MANDATORY_WARNING, "Running " + getClass().getSimpleName());

            allMaps = new HashMap<Element, ArrayList<ReqMapMethod>>();
            for (Element element : roundEnv.getElementsAnnotatedWith(RequestMapping.class)) {
                for (ExecutableElement executableElement : ElementFilter.methodsIn(element.getEnclosedElements())) {
                    RequestMapping methodMapping = executableElement.getAnnotation(RequestMapping.class);
                    if (methodMapping == null) continue;

                    Element methodClass = executableElement.getEnclosingElement();
                    RequestMapping classMapping = methodClass.getAnnotation(RequestMapping.class);

                    String classPath = getMappingFirstValue(classMapping);
                    String methodPath = getMappingFirstValue(methodMapping);
                    String fullMapping = classPath + methodPath;
                    addMapping(methodClass, executableElement, fullMapping, methodMapping);
                }
            }

            for (Element controllerType : allMaps.keySet()) {
                List<ReqMapMethod> reqMapMethods = allMaps.get(controllerType);
                writeSourceURLCodeFor(controllerType, reqMapMethods);
                writeSourceSecurityCodeFor(controllerType, reqMapMethods);
            }
        } catch (Exception e) {
            warn("There was an exception: " + e.toString() + " :: " + e.getMessage());
            throw new RuntimeException(e.getMessage(), e);
            //e.printStackTrace();
        }

        log(String.format("Runtime of %s only took %dms.", this.getClass().getName(), System.currentTimeMillis() - startMillis));
        return false; // do not "claim" the annotations, whatever it means.
    }

// -------------------------- OTHER METHODS --------------------------

    private void addMapping(final Element controllerType, final ExecutableElement methodType, final String mapping, final RequestMapping methodMapping) {
        if (!allMaps.containsKey(controllerType)) {
            allMaps.put(controllerType, new ArrayList<ReqMapMethod>());
        }
        List<ReqMapMethod> reqMapMethods = allMaps.get(controllerType);
        reqMapMethods.add(new ReqMapMethod(controllerType, methodType, mapping, methodMapping));
    }

    private String getMappingFirstValue(final RequestMapping methodMapping) {
        if (methodMapping == null) return "";
        if (methodMapping.value() == null) return "";
        if (methodMapping.value().length != 1) return ""; // not really true... but then...
        return methodMapping.value()[0];
    }

    private void writeSourceURLCodeFor(final Element controllerType, final List<ReqMapMethod> reqMapMethods) throws Exception {
        String packageName = controllerType.getEnclosingElement().asType().toString() + ".url";
        String className = controllerType.getSimpleName().toString() + "URL";
        String fqcn = packageName + "." + className;
        String originalFQCN = controllerType.getEnclosingElement().asType().toString() + "." + controllerType.getSimpleName().toString();

        // log(String.format("Generating code for [%s]", fqcn));

        // Add logic here, to avoid repeatedly generate everything all the time.
        // maven-apt-plugin calls javac with -proc:only, but passes all Java Files, so at the first round,
        // we get called with all the files, all the time.
        // So it means we have to handle the generation timestamp.
        // We should get the original class file timestamp and compare it to the generated source code file.
        // If the generated source is newer, skip generation, since we would be wasting time.

        JavaFileObject sourceFile = processingEnv.getFiler().createSourceFile(fqcn, controllerType);
        // log(String.format("File '%s'", sourceFile.toUri().toString()));

        // The trouble here is: we can't get to the source file! Should we try to get the class file?
        // No: the class file doesn't actually exist at this time...

//        JavaFileObject originalClassFile = processingEnv.getFiler().createClassFile(originalFQCN);
//        long compiledGenerated = originalClassFile.getLastModified();
//        log("Current last modified for class file " + originalClassFile.toUri().toString() + " is " + compiledGenerated);

//        long lastGenerated = sourceFile.getLastModified();
//        log("Current last modified for source file " + lastGenerated);

        Writer writingWriter = sourceFile.openWriter(); // 'coheed and cambria' reference...
        writingWriter.write("package " + packageName + ";\n");
        writingWriter.write("@javax.annotation.Generated(value = \"" + this.getClass().getName() + "\", date = \"" + new Date().toString() + "\") \n");
        writingWriter.write("public class " + className + " { \n\n\n");

        Set<String> genMethods = new HashSet<String>();

        for (ReqMapMethod reqMapMethod : reqMapMethods) {
            String methodName = reqMapMethod.methodType.getSimpleName().toString();
            String methodNameGetPost = (reqMapMethod.isPOSTMethod() ? (methodName + "POST") : methodName);
            String methodNameBinding = methodNameGetPost + "BIND";
            if (genMethods.contains(methodNameGetPost)) {
                warn(String.format("Attention: method '%s' is ambiguous in controller '%s'", methodName, originalFQCN));
                continue;
            }
            genMethods.add(methodNameGetPost);

            UriTemplate uriTemplate = new UriTemplate(reqMapMethod.mapping);
            List<String> variableNames = uriTemplate.getVariableNames();

            List<String> declParamList = new ArrayList<String>();
            List<String> listParams = new ArrayList<String>();

            List<String> declParamListBinding = new ArrayList<String>();
            List<String> listParamsBinding = new ArrayList<String>();

            String annotationsForAllParams = "@org.jetbrains.annotations.NotNull() ";
            boolean isDeprecated = false;
            for (String variableName : variableNames) {
                // @TODO: hack more in here. We can deduce the parameter types from the method's arguments!!!
                if (variableName.equalsIgnoreCase("customRootName")) {
                    declParamList.add(annotationsForAllParams + "br.com.me.ceap.model.CustomRoot customRoot");
                    listParams.add("customRoot.getName()");
                } else if (variableName.endsWith("Name")) {
                    declParamList.add(annotationsForAllParams + "String " + variableName);
                    listParams.add(variableName);
                } else {
                    final String suggestedVarName = null;
                    SuggestedVarName betterType = tryToFindBetterTypeForVariable(reqMapMethod, variableName);
                    if (betterType != null) {
                        declParamList.add(annotationsForAllParams + betterType.typeName + " " + betterType.variableName);
                        if (
                                betterType.typeName.equals("long") ||
                                        StringUtils.startsWithIgnoreCase(betterType.typeName, "java.lang.")
                                ) {
                            isDeprecated = true;
                            listParams.add(betterType.variableName + "");
                        } else {
                            listParams.add(betterType.variableName + ".getId()");
                        }
                    } else {
                        isDeprecated = true;
                        listParams.add(variableName);
                        if (variableName.endsWith("Id")) {
                            declParamList.add(annotationsForAllParams + "Long " + variableName);
                        } else {
                            declParamList.add(annotationsForAllParams + "Object " + variableName);
                        }
                    }
                }

                declParamListBinding.add(annotationsForAllParams + "String " + variableName);
                //listParamsBinding.add("\"{\" + " + variableName + "+ \"}\"");
                listParamsBinding.add(variableName);
            }

            String declParams = StringUtils.collectionToCommaDelimitedString(declParamList);
            String listParamsComma = StringUtils.collectionToCommaDelimitedString(listParams);

            writingWriter.write("\t" + "/**" + "\n");
            writingWriter.write("\t" + " * Links to the Controller " + (reqMapMethod.isPOSTMethod() ? "POST" : "GET") + " action " + methodName + ".\n");
            writingWriter.write("\t" + " * See {@link " + originalFQCN + "#" + methodName + " " + methodName + " method}." + "\n");
            writingWriter.write("\t" + " * Mapping: {@code " + reqMapMethod.mapping + "}\n");
            writingWriter.write("\t" + " */" + "\n");
            if (isDeprecated) writingWriter.write("\t" + "@java.lang.Deprecated()" + "\n");
            writingWriter.write("\tpublic static String " + methodNameGetPost + "(" + declParams + ") {\n");
            writingWriter.write("\t\treturn org.springframework.web.util.UriComponentsBuilder.fromUriString(\"" + reqMapMethod.mapping + "\").build().expand(" + listParamsComma + ").encode().toUriString();\n");
            writingWriter.write("\t}\n");


            if (reqMapMethod.willBeUsedInBinding) {
                String declParamsBinding = StringUtils.collectionToCommaDelimitedString(declParamListBinding);
                String listParamsCommaBinding = StringUtils.collectionToCommaDelimitedString(listParamsBinding);

                writingWriter.write("\t" + "/**" + "\n");
                writingWriter.write("\t" + " * Links to the Controller " + (reqMapMethod.isPOSTMethod() ? "POST" : "GET") + " action " + methodName + " (usage in binding).\n");
                writingWriter.write("\t" + " * See {@link " + originalFQCN + "#" + methodName + " " + methodName + " method}." + "\n");
                writingWriter.write("\t" + " * Every parameter will be enclosed with moustaches.\n");
                writingWriter.write("\t" + " * Mapping: {@code " + reqMapMethod.mapping + "}\n");
                writingWriter.write("\t" + " */" + "\n");
                //if (isDeprecated) writingWriter.write("\t" + "@java.lang.Deprecated()" + "\n");
                writingWriter.write("\tpublic static String " + methodNameBinding + "(" + declParamsBinding + ") {\n");
                writingWriter.write("\t\treturn org.springframework.web.util.UriComponentsBuilder.fromUriString(\"" + reqMapMethod.mapping + "\").build().expand(" + listParamsCommaBinding + ").toUriString();\n");
                writingWriter.write("\t}\n");
            }


        }

        writingWriter.write("\n\n\n}\n");
        writingWriter.close();
        //log(String.format("Wrote file '%s'", sourceFile.toUri()));
    }

    private void writeSourceSecurityCodeFor(final Element controllerType, final List<ReqMapMethod> reqMapMethods) throws Exception {
        String packageName = controllerType.getEnclosingElement().asType().toString() + ".security";
        String className = controllerType.getSimpleName().toString() + "Security";
        String fqcn = packageName + "." + className;
        String originalFQCN = controllerType.getEnclosingElement().asType().toString() + "." + controllerType.getSimpleName().toString();

        JavaFileObject sourceFile = processingEnv.getFiler().createSourceFile(fqcn, controllerType);

        Writer writingWriter = sourceFile.openWriter(); // 'coheed and cambria' reference...
        writingWriter.write("package " + packageName + ";\n");
        writingWriter.write("@javax.annotation.Generated(value = \"" + this.getClass().getName() + "\", date = \"" + new Date().toString() + "\") \n");
        writingWriter.write("public class " + className + " { \n\n\n");

        Set<String> genMethods = new HashSet<String>();

        for (ReqMapMethod reqMapMethod : reqMapMethods) {
            String methodName = reqMapMethod.methodType.getSimpleName().toString();
            String methodNameValidate = "can" + methodName.substring(0, 1).toUpperCase() + methodName.substring(1);
            if (genMethods.contains(methodNameValidate)) {
                warn(String.format("Attention: method '%s' is ambiguous in controller '%s'", methodName, originalFQCN));
                continue;
            }
            genMethods.add(methodNameValidate);

            List<String> listParams = new LinkedList<String>();
            for (VariableElement variableElement : reqMapMethod.methodType.getParameters()) {
                String paramClassName = variableElement.asType().toString();
                // Hack: If this is a Generic Type I have to exclude parameters between <>
                if (paramClassName.contains("<") && paramClassName.contains(">"))
                    paramClassName = paramClassName.substring(0, paramClassName.indexOf("<"));
                listParams.add(paramClassName + ".class");
            }
            String listParamsComma = StringUtils.collectionToCommaDelimitedString(listParams);

            writingWriter.write("\t" + "/**" + "\n");
            writingWriter.write("\t" + " * Check if the action " + methodName + " can be accessed by logged user according to Roles (Principal).\n");
            writingWriter.write("\t" + " * See {@link " + originalFQCN + "#" + methodName + " " + methodName + " method}." + "\n");
            writingWriter.write("\t" + " */" + "\n");
            writingWriter.write("\tpublic static Boolean " + methodNameValidate + "() {\n");
            writingWriter.write("\t\ttry {\n");
            writingWriter.write("\t\t\tbr.com.me.ceap.web.security.SpringSecurityValidator.validate(\n");
            if (listParamsComma.isEmpty())
                writingWriter.write("\t\t\t\t" + originalFQCN + ".class.getDeclaredMethod(\"" + methodName + "\", new Class[0])\n");
            else
                writingWriter.write("\t\t\t\t" + originalFQCN + ".class.getDeclaredMethod(\"" + methodName + "\"," + listParamsComma + ")\n");
            writingWriter.write("\t\t\t);\n");
            writingWriter.write("\t\t\treturn true;\n");
            writingWriter.write("\t\t} catch (org.springframework.security.access.AccessDeniedException e) {\n");
            writingWriter.write("\t\t\treturn false;\n");
            writingWriter.write("\t\t} catch (org.springframework.security.authentication.InsufficientAuthenticationException e) {\n");
            writingWriter.write("\t\t\treturn false;\n");
            writingWriter.write("\t\t} catch (java.lang.NoSuchMethodException e) {\n");
            writingWriter.write("\t\t\treturn false;\n");
            writingWriter.write("\t\t}\n");
            writingWriter.write("\t}\n");
        }

        writingWriter.write("\n\n\n}\n");
        writingWriter.close();
    }


    private SuggestedVarName tryToFindBetterTypeForVariable(final ReqMapMethod reqMapMethod, final String variableName) {
        String withoutId = org.apache.commons.lang.StringUtils.removeEndIgnoreCase(variableName, "Id");

        // Search by name...
        for (VariableElement variableElement : reqMapMethod.methodType.getParameters()) {
            if (variableName.equalsIgnoreCase(variableElement.getSimpleName().toString())) {
                return new SuggestedVarName(variableName, variableElement.asType().toString());
            } else if (withoutId.equalsIgnoreCase(variableElement.getSimpleName().toString())) {
                return new SuggestedVarName(withoutId, variableElement.asType().toString());
            }
        }

        // Search by type...
        for (VariableElement variableElement : reqMapMethod.methodType.getParameters()) {
            String typeName = variableElement.asType().toString();
            String classType = StringUtils.unqualify(typeName);
            //log("Searching by type for: " + classType);

            if (variableName.equalsIgnoreCase(classType)) {
                return new SuggestedVarName(variableName, typeName);
            } else if (withoutId.equalsIgnoreCase(classType)) {
                return new SuggestedVarName(withoutId, typeName);
            }

        }


        // Try to forcefully find from model. This an ugly hack... (as if the rest wasn't)


        return null;
    }

    private void log(String msg) {
        //System.out.println(msg);
        processingEnv.getMessager().printMessage(Diagnostic.Kind.NOTE, msg);
    }

    private void warn(final String msg) {
        processingEnv.getMessager().printMessage(Diagnostic.Kind.MANDATORY_WARNING, msg);
    }

// -------------------------- INNER CLASSES --------------------------

    private class ReqMapMethod {
        Element controllerType;
        ExecutableElement methodType;
        String mapping;
        RequestMapping requestMapping;
        boolean willBeUsedInBinding;


        private ReqMapMethod(final Element controllerType, final ExecutableElement methodType, final String mapping, final RequestMapping methodMapping) {
            //warn("Creating ReqMapMethod for " + controllerType.getSimpleName() + " method " + methodType.getSimpleName());
            this.controllerType = controllerType;
            this.methodType = methodType;
            this.mapping = mapping;
            this.requestMapping = methodMapping;
            willBeUsedInBinding = false;
            this.findOtherAnnotations();
        }

        private void findOtherAnnotations() {
            for (AnnotationMirror annotationMirror : methodType.getAnnotationMirrors()) {
                if (annotationMirror.toString().equals("@br.com.me.ceap.util.annotations.URLUsedInBinding")) {
                    willBeUsedInBinding = true;
                    //warn("Will be used in binding!");
                }
            }
        }

        private boolean isPOSTMethod() {
            if (this.requestMapping.method() == null) return false;
            for (RequestMethod requestMethod : requestMapping.method()) {
                if (requestMethod.equals(RequestMethod.POST)) return true;
            }
            return false;
        }
    }

    private class SuggestedVarName {
        public String variableName;
        public String typeName;

        public SuggestedVarName(final String variableName, final String typeName) {
            this.variableName = variableName;
            this.typeName = typeName;
        }
    }
}
